developer.apple.com/tutorials/swiftui/animating-views-and-transitions
创建数据模型
- 添加hikeData.json文件到项目
- 创建数据模型 Hike.swift
swift
import Foundation
struct Hike: Codable, Hashable, Identifiable {
var id: Int
var name: String
var distance: Double
var difficulty: Int
var observations: [Observation]
static var formatter = LengthFormatter()
var distanceText: String {
Hike.formatter
.string(fromValue: distance, unit: .kilometer)
}
struct Observation: Codable, Hashable {
var distanceFromStart: Double
var elevation: Range<Double>
var pace: Range<Double>
var heartRate: Range<Double>
}
}
import Foundation
struct Hike: Codable, Hashable, Identifiable {
var id: Int
var name: String
var distance: Double
var difficulty: Int
var observations: [Observation]
static var formatter = LengthFormatter()
var distanceText: String {
Hike.formatter
.string(fromValue: distance, unit: .kilometer)
}
struct Observation: Codable, Hashable {
var distanceFromStart: Double
var elevation: Range<Double>
var pace: Range<Double>
var heartRate: Range<Double>
}
}
3). ModelData.swift文件中增加模型数据导入
swift
import Foundation
//var landmarks: [Landmark] = load("landmarkData.json")
// SwiftUI订阅(subscribes)您的可观察(observable)对象,并在数据变化时更新任何需要刷新的视图。
final class ModelData: ObservableObject {
// 可观察对象需要发布对其数据的任何更改,以便其订阅者能够接收更改。
@Published var landmarks: [Landmark] = load("landmarkData.json")
var hikes: [Hike] = load("hikeData.json")
}
func load<T: Decodable>(_ filename: String) -> T {
let data: Data
guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
else {
fatalError("Couldn't find \(filename) in main bundle.")
}
do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}
import Foundation
//var landmarks: [Landmark] = load("landmarkData.json")
// SwiftUI订阅(subscribes)您的可观察(observable)对象,并在数据变化时更新任何需要刷新的视图。
final class ModelData: ObservableObject {
// 可观察对象需要发布对其数据的任何更改,以便其订阅者能够接收更改。
@Published var landmarks: [Landmark] = load("landmarkData.json")
var hikes: [Hike] = load("hikeData.json")
}
func load<T: Decodable>(_ filename: String) -> T {
let data: Data
guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
else {
fatalError("Couldn't find \(filename) in main bundle.")
}
do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}
图形绘制
绘制图形:
Capsule组件说明
SwiftUI 中的 Capsule 可以用来创建圆角矩形,并且可以指定圆角的大小。Capsule 可以设置填充颜色、边框颜色、边框宽度等属性。
swift
import SwiftUI
struct CapsuleDemo: View {
var body: some View {
Capsule()
// 都可以设置颜色,测试发现fill优先级更高
.fill(.yellow)
.foregroundColor(.green)
// 可以看到背景色是整个组件的背景
//.background(.red)
// 圆角是整个组件的圆角,所以这个属性不要设置
//.cornerRadius(200)
.padding(30)
.shadow(color:.gray, radius: 10, x: 10, y: 10)
.overlay {
Text("Capsule")
.foregroundColor(.red)
}
}
}
struct CapsuleDemo_Previews: PreviewProvider {
static var previews: some View {
CapsuleDemo()
}
}
import SwiftUI
struct CapsuleDemo: View {
var body: some View {
Capsule()
// 都可以设置颜色,测试发现fill优先级更高
.fill(.yellow)
.foregroundColor(.green)
// 可以看到背景色是整个组件的背景
//.background(.red)
// 圆角是整个组件的圆角,所以这个属性不要设置
//.cornerRadius(200)
.padding(30)
.shadow(color:.gray, radius: 10, x: 10, y: 10)
.overlay {
Text("Capsule")
.foregroundColor(.red)
}
}
}
struct CapsuleDemo_Previews: PreviewProvider {
static var previews: some View {
CapsuleDemo()
}
}
创建GraphCapsule充当“柱”图
swift
import SwiftUI
struct GraphCapsule: View, Equatable {
var index: Int
var color: Color
var height: CGFloat
// 当前数据的范围区间
var range: Range<Double>
// 全部范围区间
var overallRange: Range<Double>
// 根据数据的范围,和全部范围区间进行比较,计算出占比。最小比例是0.15
var heightRatio: CGFloat {
// magnitude自定义函数作用是取区间返回大小 range.upperBound - range.lowerBound
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
// 当前数据,起始点数据的偏移比例
var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}
var body: some View {
Capsule()
.fill(color)
.frame(height: height * heightRatio)
.offset(x: 0, y: height * -offsetRatio)
}
}
struct GraphCapsule_Previews: PreviewProvider {
static var previews: some View {
GraphCapsule(
index: 0,
color: .red,
height: 150,
range: 10..<50,
overallRange: 0..<100)
}
}
import SwiftUI
struct GraphCapsule: View, Equatable {
var index: Int
var color: Color
var height: CGFloat
// 当前数据的范围区间
var range: Range<Double>
// 全部范围区间
var overallRange: Range<Double>
// 根据数据的范围,和全部范围区间进行比较,计算出占比。最小比例是0.15
var heightRatio: CGFloat {
// magnitude自定义函数作用是取区间返回大小 range.upperBound - range.lowerBound
max(CGFloat(magnitude(of: range) / magnitude(of: overallRange)), 0.15)
}
// 当前数据,起始点数据的偏移比例
var offsetRatio: CGFloat {
CGFloat((range.lowerBound - overallRange.lowerBound) / magnitude(of: overallRange))
}
var body: some View {
Capsule()
.fill(color)
.frame(height: height * heightRatio)
.offset(x: 0, y: height * -offsetRatio)
}
}
struct GraphCapsule_Previews: PreviewProvider {
static var previews: some View {
GraphCapsule(
index: 0,
color: .red,
height: 150,
range: 10..<50,
overallRange: 0..<100)
}
}
创建柱状图
创建HikeGraph.swift页面:
swift
import SwiftUI
struct HikeGraph: View {
var hike: Hike
// Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
// 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
// 例如: 这里代表 Hike.Observation 这个类型如何访问到 Range<Double>
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
// lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
GraphCapsule(
index: index,
color: color,
height: proxy.size.height,
range: observation[keyPath: path],
overallRange: overallRange
)
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
range.upperBound - range.lowerBound
}
struct HikeGraph_Previews: PreviewProvider {
static var hike = ModelData().hikes[0]
static var previews: some View {
Group {
HikeGraph(hike: hike, path: \.elevation)
.frame(height: 200)
HikeGraph(hike: hike, path: \.heartRate)
.frame(height: 200)
HikeGraph(hike: hike, path: \.pace)
.frame(height: 200)
}
}
}
import SwiftUI
struct HikeGraph: View {
var hike: Hike
// Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
// 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
// 例如: 这里代表 Hike.Observation 这个类型如何访问到 Range<Double>
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
// lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
GraphCapsule(
index: index,
color: color,
height: proxy.size.height,
range: observation[keyPath: path],
overallRange: overallRange
)
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
range.upperBound - range.lowerBound
}
struct HikeGraph_Previews: PreviewProvider {
static var hike = ModelData().hikes[0]
static var previews: some View {
Group {
HikeGraph(hike: hike, path: \.elevation)
.frame(height: 200)
HikeGraph(hike: hike, path: \.heartRate)
.frame(height: 200)
HikeGraph(hike: hike, path: \.pace)
.frame(height: 200)
}
}
}
尝试为单个视图增加动画
我们可以在equatable view上,使用使用修饰器 animation(_:)
, 可以对视图的可动画属性的修改产生动画。 view的color、opacity、rotation、size以及其他属性都是可以产生动画的。如果view不是equatable的,我们可以使用animation(_:)
修饰器在指定值发生变化时启动动画。
swift
import SwiftUI
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
showDetail.toggle()
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
// 根据动画状态,进行图标旋转
.rotationEffect(.degrees(showDetail ? 90 : 0))
// 根据动画状态,调整大小
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
// 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
// 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
.animation(.easeInOut, value: showDetail)
}
}
if showDetail {
HikeDetail(hike: hike)
}
Spacer()
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
}
import SwiftUI
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
showDetail.toggle()
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
// 根据动画状态,进行图标旋转
.rotationEffect(.degrees(showDetail ? 90 : 0))
// 根据动画状态,调整大小
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
// 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
// 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
.animation(.easeInOut, value: showDetail)
}
}
if showDetail {
HikeDetail(hike: hike)
}
Spacer()
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
}
效果演示如图:
额外的,使用.animation(nil, value: showDetail)
将会取消掉上文中的旋转动画效果。
swift
import SwiftUI
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
showDetail.toggle()
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
// 根据动画状态,进行图标旋转
.rotationEffect(.degrees(showDetail ? 90 : 0))
// 此操作会取消掉上文中的旋转动画效果
.animation(nil, value: showDetail)
// 根据动画状态,调整大小
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
// 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
// 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
//.animation(.easeInOut, value: showDetail)
.animation(.spring(), value: showDetail)
}
}
if showDetail {
HikeDetail(hike: hike)
}
Spacer()
}
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
import SwiftUI
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
showDetail.toggle()
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
// 根据动画状态,进行图标旋转
.rotationEffect(.degrees(showDetail ? 90 : 0))
// 此操作会取消掉上文中的旋转动画效果
.animation(nil, value: showDetail)
// 根据动画状态,调整大小
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
// 添加一个动画修饰器,当showDetail状态改变的时候,为按钮的旋转打开动画
// 动画修饰器对于其封装的视图中的所有动画都起效果,因此旋转和缩放都被增加上了动画效果
//.animation(.easeInOut, value: showDetail)
.animation(.spring(), value: showDetail)
}
}
if showDetail {
HikeDetail(hike: hike)
}
Spacer()
}
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
状态变化的动画效果
使用withAnimation包裹住showDetail.toggle()
。 (测试发现HikeDetail出现的时候没有带动画,这里没有深究)
swift
import SwiftUI
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
// 增加withAnimation包裹住
// 这样使得受showDetail影响的视图(按钮和详情)都增加上了动画效果
// withAnimation {
// 为了效果更明显,这里增加了4s时间
withAnimation(.easeInOut(duration: 4)) {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
}
Spacer()
}
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
import SwiftUI
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
// 增加withAnimation包裹住
// 这样使得受showDetail影响的视图(按钮和详情)都增加上了动画效果
// withAnimation {
// 为了效果更明显,这里增加了4s时间
withAnimation(.easeInOut(duration: 4)) {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
}
Spacer()
}
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
自定义转场动画
swift
import SwiftUI
extension AnyTransition {
static var moveAndFade: AnyTransition {
// AnyTransition.slide
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
)
}
}
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
withAnimation {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
// 图形通过滑入和滑出的方式出现和消失
// .transition(.slide)
// 自定义的动画
.transition(.moveAndFade)
}
Spacer()
}
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
import SwiftUI
extension AnyTransition {
static var moveAndFade: AnyTransition {
// AnyTransition.slide
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .scale.combined(with: .opacity)
)
}
}
struct HikeView: View {
var hike: Hike
@State private var showDetail = false
var body: some View {
VStack {
HStack {
HikeGraph(hike: hike, path: \.elevation)
.frame(width: 50, height: 30)
VStack(alignment: .leading) {
Text(hike.name)
.font(.headline)
Text(hike.distanceText)
}
Spacer()
Button {
withAnimation {
showDetail.toggle()
}
} label: {
Label("Graph", systemImage: "chevron.right.circle")
.labelStyle(.iconOnly)
.imageScale(.large)
.rotationEffect(.degrees(showDetail ? 90 : 0))
.scaleEffect(showDetail ? 1.5 : 1)
.padding()
}
}
if showDetail {
HikeDetail(hike: hike)
// 图形通过滑入和滑出的方式出现和消失
// .transition(.slide)
// 自定义的动画
.transition(.moveAndFade)
}
Spacer()
}
}
}
struct HikeView_Previews: PreviewProvider {
static var previews: some View {
HikeView(hike: ModelData().hikes[0])
}
}
组合复杂动画
当您点击柱形图下方的按钮时,图形将在三组不同的数据之间切换。在本节中,您将使用合成动画为组成图形的胶囊提供动态的波纹过渡。
swift
import SwiftUI
extension Animation {
static func ripple(index: Int) -> Animation {
// 默认动画
// Animation.default
// 弹性动画
//Animation.spring(dampingFraction: 0.5)
// 调整弹性动画参数
Animation.spring(dampingFraction: 0.5)
// 稍稍加快动画速度,缩短每个条移动到新位置的时间。
.speed(2)
// 根据胶囊在图形上的位置为每个动画添加延迟
.delay(0.03 * Double(index))
}
}
struct HikeGraph: View {
var hike: Hike
// Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
// 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
// 例如: 这里代表 Hike.Observation 这个类型如何访问到 Range<Double>
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
// lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
GraphCapsule(
index: index,
color: color,
height: proxy.size.height,
range: observation[keyPath: path],
overallRange: overallRange
)
// 提供动画效果
.animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
range.upperBound - range.lowerBound
}
struct HikeGraph_Previews: PreviewProvider {
static var hike = ModelData().hikes[0]
static var previews: some View {
Group {
HikeGraph(hike: hike, path: \.elevation)
.frame(height: 200)
HikeGraph(hike: hike, path: \.heartRate)
.frame(height: 200)
HikeGraph(hike: hike, path: \.pace)
.frame(height: 200)
}
}
}
import SwiftUI
extension Animation {
static func ripple(index: Int) -> Animation {
// 默认动画
// Animation.default
// 弹性动画
//Animation.spring(dampingFraction: 0.5)
// 调整弹性动画参数
Animation.spring(dampingFraction: 0.5)
// 稍稍加快动画速度,缩短每个条移动到新位置的时间。
.speed(2)
// 根据胶囊在图形上的位置为每个动画添加延迟
.delay(0.03 * Double(index))
}
}
struct HikeGraph: View {
var hike: Hike
// Swift5.2引入了 KeyPath<Root,Value>,这是个泛型类型
// 用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。
// 例如: 这里代表 Hike.Observation 这个类型如何访问到 Range<Double>
var path: KeyPath<Hike.Observation, Range<Double>>
var color: Color {
switch path {
case \.elevation:
return .gray
case \.heartRate:
return Color(hue: 0, saturation: 0.5, brightness: 0.7)
case \.pace:
return Color(hue: 0.7, saturation: 0.4, brightness: 0.7)
default:
return .black
}
}
var body: some View {
let data = hike.observations
// lazy: 包含与本序列相同元素的序列,但其上的某些操作(如map和filter)是lazy的
let overallRange = rangeOfRanges(data.lazy.map { $0[keyPath: path] })
let maxMagnitude = data.map { magnitude(of: $0[keyPath: path]) }.max()!
let heightRatio = 1 - CGFloat(maxMagnitude / magnitude(of: overallRange))
return GeometryReader { proxy in
HStack(alignment: .bottom, spacing: proxy.size.width / 120) {
ForEach(Array(data.enumerated()), id: \.offset) { index, observation in
GraphCapsule(
index: index,
color: color,
height: proxy.size.height,
range: observation[keyPath: path],
overallRange: overallRange
)
// 提供动画效果
.animation(.ripple(index: index))
}
.offset(x: 0, y: proxy.size.height * heightRatio)
}
}
}
}
func rangeOfRanges<C: Collection>(_ ranges: C) -> Range<Double>
where C.Element == Range<Double> {
guard !ranges.isEmpty else { return 0..<0 }
let low = ranges.lazy.map { $0.lowerBound }.min()!
let high = ranges.lazy.map { $0.upperBound }.max()!
return low..<high
}
func magnitude(of range: Range<Double>) -> Double {
range.upperBound - range.lowerBound
}
struct HikeGraph_Previews: PreviewProvider {
static var hike = ModelData().hikes[0]
static var previews: some View {
Group {
HikeGraph(hike: hike, path: \.elevation)
.frame(height: 200)
HikeGraph(hike: hike, path: \.heartRate)
.frame(height: 200)
HikeGraph(hike: hike, path: \.pace)
.frame(height: 200)
}
}
}